Skip to content

feat(home): Rebuild the homepage on a declarative motion system - #115

Open
traksaw wants to merge 28 commits into
mainfrom
waskar/site-redesign
Open

feat(home): Rebuild the homepage on a declarative motion system#115
traksaw wants to merge 28 commits into
mainfrom
waskar/site-redesign

Conversation

@traksaw

@traksaw traksaw commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Rebuilds the homepage from the Claude Design source (Homepage.dc.html), moves
its choreography onto a declarative motion system that pages drive from markup
rather than inline GSAP, and fills "The room" with photos from four real Builder
Nights.

The homepage

src/pages/index.astro is the redesign: the hero, the next-night bar, the three
programme tracks, "The room", the open-source cards, and the pigeon CTA, on the
design's own tokens. Where the design asserted something this repo could not
back, the page says less rather than more, and each of those decisions is a
comment next to the code that makes it:

The motion system

src/scripts/motion/ is the whole vocabulary. Pages declare data-pcv-arrive
and data-pcv-hold; the scanner in index.ts is the only file that decides what
animates. A contributor who has never opened a GSAP doc gets motion matching the
rest of the site, because there is exactly one implementation of each move.

Everything gated on motion preference lives in a single gsap.matchMedia()
block. A JS branch plus a parallel CSS block is two sources of truth that drift,
and matchMedia also reverts its tweens on cleanup, which for a from() restores
the element's real visible state.

Section.astro gives each band a story position — open air, the door, the room,
the invitation — as a prop that cannot be quietly ignored, unlike a convention.

The room, and its photos

The mosaic renders only when the gallery collection has photos, because "Every
photo is a real Thursday" is a claim only photos can honour — shipping it over
placeholder boxes is #98 at larger scale. Six entries now come from PATCH 001,
the Hackathon Kickoff + Builder Night, Collab Lab, and the Kickback + Game Night,
with event names taken from the attendance records in community/events rather
than guessed.

Every candidate from the May 5 shoot is excluded despite being by far the
strongest photography we have: that was Philly Tech Week, on a Tuesday, and the
headline says Thursday. Changing that copy would unlock 31 better photos — worth
deciding separately.

Images carry a fixed -400/-800 srcset ladder beside a 1200px canonical.
Measured on the built site, a 390px phone at 3x goes 674KB to 308KB.
e2e/gallery-images.spec.ts fetches every candidate URL, so a missing variant
fails CI instead of shipping a 404 only phones would hit. Deliberately not
astro:assets, which would generate this automatically — its image service needs
sharp, and adding a native binary dependency to a repo aimed at first-time
contributors is a maintainer's call.

The skyline is gone, deliberately

It was built and then tried three ways: panned sideways, grown by animating the
band's height, and scaled inside a fixed band. None of them was the right idea
rather than the wrong one executed badly, so f1ba32a removes it. The
brand.sky token stays because it is the hero's own background now.

That history is left in the commits rather than squashed — the next person to
propose a skyline should be able to see what was already tried.

Two latent bugs fixed along the way

  • The gallery collection's loader advertises .json, but its schema used
    z.date(), which only ever sees a Date because YAML frontmatter parses one
    before Zod runs. Every JSON entry that could be written was rejected at build
    time — unnoticed because the collection stayed empty.
  • public/.DS_Store was already tracked, so it was being served as a real file.
    Adding the ignore rule does not untrack it.

Worth a reviewer's attention

  • The count-up and the pinned room section are gated at min-width: 1024px.
    Below that nothing pins, so a 0.9s count is not read, it is a flicker of wrong
    numbers going past. e2e/mobile-motion.spec.ts runs at a phone viewport
    specifically to assert that gate holds.
  • e2e/no-js.spec.ts asserts the site degrades flat rather than blank, which is
    the risk of moving entrances into JS at all.
  • public/js/scroll-progress.js is plain JS in public/ rather than a component
    <script> on purpose. As the latter, Astro inlined it and the enforcing CSP
    blocked it on every page — an inline script needs its sha256 maintained in both
    policies in vercel.json. Same trade event-freshness.js already makes.
  • Header height goes 68px to 62px, which is what HEADER_OFFSET has always
    assumed when parking a pinned section. The two disagreed by six pixels.
  • docs/perf-baseline.md records Lighthouse numbers taken before the motion
    system, and argues against its own precision: the local run-to-run spread is
    six times the ±3 gate the spec sets. It predates the mosaic shipping real
    images, so re-baseline in CI before that gate blocks anything.
  • BuilderNightTrack.astro and its spec are deleted; that job is now the tracks
    grid on the homepage.

traksaw and others added 26 commits July 31, 2026 13:15
Port all seven sections of the redesigned homepage: hero, sky band, the
three "what happens" cards, the room, open source repos, and the join CTA.

Every factual claim on the page reads from getCommunityData rather than
being written out, so the two copies of the member/nights/shipped line
cannot disagree with each other or go stale between builds.

Three deliberate departures from the design source:

- The design's WebGL city is not here. three.js cost 504 KB — three
  quarters of all JavaScript on the site — and ~2.5s of main-thread
  blocking on our highest-traffic page, for decoration. Total page JS is
  168 KB instead of 676 KB. GSAP stays; it drives the scroll
  choreography and is a fraction of the weight.
- The design named a repo (p-l-otHole) and a night (Vision Lab at Indy
  Hall) that do not exist, and hardcoded issue counts that were already
  wrong. Those are djVisualizer, Workshops, and live filtered links.
- The photo mosaic and its "Every photo is a real Thursday" headline
  render only when the gallery collection has entries, rather than
  putting the claim over placeholder boxes.

The redesign has no Builder Night timeline, which leaves
BuilderNightTrack used by no page, so it and its specs are removed along
with the loop animation styles only it used. The one
motion-preferences test that targeted it now covers the GSAP scroll
reveals instead — those hide content by animating from opacity 0, so
they are the reveal worth asserting under reduced motion.

public/images/skyline.png could not be transferred from the design
project and the sky band renders as a flat colour field until it lands.
Restoring it is un-commenting four lines in SkylineBand.astro.

Fixes #98
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbgB3FYHNAGVgvYDjXCsKB
The band shipped as a flat colour field because the design project's
skyline.png could not be transferred. The source art we do have is the
PhilaCon Valley poster, which is portrait: its rooflines span 2060px of a
3900px width, roughly 1.6:1, where the band is closer to 5.5:1. No crop
of it both fills the band and keeps the rooflines in frame.

So the tile is the poster's building cluster with the sky knocked out,
joined to a mirrored copy of itself. Mirroring makes the tile's left and
right edges identical, so it repeats with no seam at any viewport width.
It is rolled half a cluster sideways so the mirror axis lands mid-block
rather than on the matching pair of towers, where the symmetry would read.

The band uses background-size: auto 100% with repeat-x rather than an
<img>. That is what earns the tiling: the buildings scale to the band's
height and repeat sideways to fill it, so rooflines stay in frame and
bases stay flush from 390px up. A fixed-aspect <img> would have to commit
to one ratio and be cropped or shrunk away from it everywhere else — at
390px wide the skyline would collapse to a 65px sliver above the fold.

The wrapper takes back the role="img" and aria-label the earlier commit
left a note for; there is now an illustration to describe, so it is no
longer aria-hidden.

Encoded near-lossless at 1400x592 — 128 KB, 51.5 dB against the lossless
encode, and at least 2x the 614px it renders at in the tallest band.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`.pcv-enter-word`, `.pcv-enter-stat` and `.animate-slide-up` — with their
pcvWord, pcvStat and slideUp keyframes — are referenced by no .astro file.
The first two were written for a hero the redesign replaced; the third
predates it. All three were still being listed in the reduced-motion
override block, which is how they read as live code.

Also adds the implementation plan for the scroll choreography this is
clearing the ground for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The stat line was one interpolated string rendered in two places, which
made the copies impossible to disagree but also left nothing to animate:
a counter needs an element per number, and there were none.

StatLine keeps the guarantee by moving it up a level — one component, one
source, two call sites — and gives each number its own span carrying
`data-count-to`. The span's text is already the final value, so the claim
on the page is true before any script runs and stays true if none does.

Pluralisation moves onto the label rather than the number, so the number
stays parseable by whatever animates it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… settle

The choreography is about to grow past what belongs inline in a page
template. Moving it to src/scripts/ keeps index.astro readable and the
motion reviewable on its own. Astro bundles the import, so it stays
covered by script-src 'self' and adds no inline hash to maintain — the
CSP allowlist is unchanged at one script.

Adds a refresh ScrollTrigger was missing. It caches each trigger's
position at creation, and two things move those positions afterwards:
event-freshness.js removes the whole next-night band once the event has
started, and webfonts swap in and change the height of every text block.
ScrollTrigger refreshes on `load`, which covers the first; fonts can
settle after that, so this asks for one more once they have.

The 62px header offset becomes a named constant — pinning needs the same
number, and two copies of it would drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The homepage shipped 44 KB of scroll runtime to ease anchor scrolling and
fade nine elements up — both of which IntersectionObserver and
scroll-behavior do natively. This adds the two effects that actually
need a scroll runtime, and nothing else.

The skyline scrubs sideways against the hero. It is bound to scroll
position rather than a crossing, so it reverses when you scroll back up;
an observer fires once, in one direction. The band's tile is
mirror-seamless, so moving it sideways exposes no edge — the seam from
the previous commit is what makes the effect possible at all.

The room holds still while its numbers count up. Pinning is the other
thing no observer can do: sticky can hold an element but cannot drive a
timeline against the distance travelled while it holds.

The pin forces a layout consequence, applied from JS rather than the
markup: the room is 342px tall in a 900px viewport, so pinning it as-is
would freeze the page with the next section half in frame. It grows to
fill the screen only in the case that pins — desktop, motion allowed —
so phones and reduced-motion visitors keep today's layout untouched.

Also closes the reveal gap on the photo mosaic, which would otherwise
pop in under an animated heading once the gallery has entries.

Deliberately not built: the horizontal card track. Measured at 296px of
travel with three cards at their real width, 656px only after widening
them to make it legible. The page does not have the content to earn it.

Tests assert the two properties that are the whole argument — the
parallax reverses, and the section stops moving while the page keeps
scrolling — from what a visitor can observe rather than from
ScrollTrigger's internals, plus that both go away under reduced motion
with the counters left on their true values.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ways

The sideways scrub was the wrong reading of the idea. The city is meant
to rise to meet you on the way down the page, not slide past. The band
now grows from 260px to 480px on desktop (180 to 300 on phones) as you
scroll the first 560px, pushing the rest of the page ahead of it. The
tile is bottom-anchored and sized `auto 100%`, so the artwork scales with
the band — taller band, bigger city, still seamless across the width.

It stays a scrub rather than a crossing, so it reverses on the way back
up. That is still the property no IntersectionObserver can give us, and
still the reason the library is here.

Growing an element mid-document moves every trigger position below it, so
the room's pin would otherwise fire at a stale offset. The growth ends
well before the room comes into range, so one refresh at each end of the
range is enough; refreshing during the scrub would cost a layout pass per
frame for nothing.

Fixes a false claim found while testing this. The counters were children
of the pin's scrubbed timeline, which meant the tween owned the text: any
instant jump past the room — restored scroll position, End key, an anchor
— rendered them once at progress 0 and stopped, leaving "0 members · 0
nights held · 0 things shipped" on the page permanently. Reproduced on
every instant jump, never on a gradual scroll, which is why it survived
the first pass. The count-up is now a one-shot tween that always runs to
its end, and if it never fires the markup's own true value is left alone.
It also no longer counts down when you scroll back up, which read as
members leaving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… band

Growing the band pushed the whole page down ahead of it, which made the
effect read as the layout lurching rather than as the city getting
closer. The band now keeps its height and the artwork scales up inside
it, anchored to the bottom, so the buildings rise out of frame as though
you were walking toward them.

Scaling the background instead of the box takes the effect off the layout
path entirely. Nothing below the band moves, no trigger position
downstream goes stale, and there is no reflow per frame — which removes
both ScrollTrigger.refresh() calls the previous approach needed and the
layout pass it cost on every scroll event.

The scale is written through a custom property rather than tweened as a
background-size string: `auto 100%` has a keyword in it, and string
interpolation across that is not worth relying on.

Capped at 140%. Past roughly 150% the rooflines and the sky above them
both crop out and the illustration stops being a skyline — it becomes a
wall of building facade. 140% is a plainly visible zoom that still keeps
a horizon in frame.

The band tests invert with it: they now assert the artwork changes size
while the band and everything below it do not move at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The skyline was tried three ways — panned sideways, grown by animating
the band's height, and scaled inside a fixed band — and none of them was
the right idea rather than the wrong one executed badly. So it goes.

The band goes with the artwork rather than being left empty. Its
background is bg-brand-sky, the same #54B5FC as the hero directly above
it, so with no illustration in it the band renders identically to hero
padding: 260px that cannot be seen but still has to be maintained. The
hero now runs straight into the next-night bar.

Removes public/images/skyline.webp, the SkylineBand component, its scroll
choreography, and the band's e2e coverage. The pinned room section and
its counters are untouched — that is still the effect carrying GSAP, and
its tests still assert it.

The brand.sky token stays: it is the hero's own background now, and the
comment explaining it no longer points at a component that does not
exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eleven pages become five. Color stops meaning which page you are on and
starts meaning where you are in the story, so every page opens on the same
front door and a section sliding over another means the same thing every
time it happens.

The visual material is deliberately unchanged — hard borders, solid offset
shadows, no gradients. Depth is earned by motion instead, which is what
makes GSAP structural here rather than decorative: kill the JavaScript and
the site goes flat.

Also records the hole this design has to close. The homepage claims every
photo is a real Thursday and the gallery collection is empty, so the site
sells belonging with no human being on it. Real event photography and live
GitHub contributors are load-bearing, and slices 4 and 7 do not merge
without them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
Five tasks: capture a Lighthouse baseline, extract the choreography into a
declarative motion system, prove the humanised stagger, introduce Section.astro,
and assert the site degrades flat rather than blank without JavaScript.

Two refinements against the spec, both recorded in the plan. Only arrive, hold
and the count-up ship here — depth and seam are visible by definition and
cannot be tested inside an invisible refactor, so they move to slice 4 where
the homepage actually uses them. And Section records its role without rendering
a colour, so adopting the component and restyling the site stay separately
reviewable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
… system

Pages now declare motion with data-pcv-* attributes instead of the script
reaching in by CSS selector, so a contributor can add motion without opening
a GSAP doc and without inventing a second way to do it.

Behaviour is unchanged. The existing scroll-choreography and motion-preferences
specs pass unmodified, which is the point: an invisible refactor proved by a
suite written before it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
Colour is about to stop meaning which page you are on and start meaning where
you are in the story. Putting that rule in a component rather than a doc is
what stops it decaying: five roles, no others.

No colour changes here. The role is recorded, not yet rendered, so this stays
reviewable and slice 3 is a single-file change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
Depth is earned by motion in this design, so the failure mode of a missing
bundle has to be a flat page, never an empty one. Cheap to assert now, and
expensive to retrofit once depth and seams land.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
Three ways the motion system could outlive its own gate, all with the same
shape: something is applied inside a `matchMedia` block and then keeps
applying after that block no longer holds.

The count-up ran in the plain `no-preference` context with no width
condition, so it fired on phones. The module it replaced ran it inside
`if (room && counters.length && window.innerWidth >= 1024)`. The gate is not
arbitrary: below 1024px nothing pins, so the band slides past at scroll speed
and a 0.9s count is not read, it is a flicker of numbers that are briefly
false. It now shares the pin's context.

`hold()` set minHeight, display, flexDirection and justifyContent straight
onto `element.style`. A `gsap.context` can only revert what it created, and
hand-written inline styles are invisible to it, so a desktop visitor who
narrowed the window past 1024px lost the pin but kept
`min-height: calc(100vh - 62px)` and the flex centring forever — a
phone-width band stretched to a full viewport. Routing them through
`gsap.set()` makes them zero-duration tweens the context records and puts
back. Chosen over returning a cleanup function from the `media.add` callback
because it keeps the knowledge of *which* properties the pin owns inside
`hold()`, where they are set, rather than duplicating that list in the
scanner where it can drift.

`countUp()` guaranteed its true value on `onComplete` only. A tween killed
mid-flight — the visitor flips the OS motion preference while it runs, the
context reverts — never reaches `onComplete`, stranding a half-counted "217
members" on screen permanently. That is exactly the defect the one-shot
design exists to prevent, reached by another route, so `onInterrupt` now
writes the same final value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
`arrive()` gave every element its own ScrollTrigger at `top 88%` and an
unbounded `i * 0.09 + jitter` delay. Both parts are wrong at scale, in the
same place: the twelve-photo mosaic. Photo twelve would wait ~1.05s after
entering view, which stops reading as people arriving and starts reading as a
broken page. It is latent on the homepage today too — the moment the first
gallery photo lands, `#nights`'s heading becomes index 5 of its group and its
delay jumps from 0 to ~0.49s for no reason a visitor could name.

The cap is `Math.min(index, 3)` rather than the old module's `(i % 3) * 0.09`.
A modulo was correct only for the three-column grid it was written for: it
drops the delay all the way back to zero every third element, so element four
arrives a full step before element three in any group that is not exactly
three wide. `Math.min` keeps the sequence ordered up to the cap and then
simply stops the wait growing — everything past the fourth item lands
together, within jitter, at ~0.27s. The jitter is applied after the clamp and
kept: the irregularity is the design property, the cap is only a bound.

The per-element trigger is the second half. It is right for a column of
blocks that cross the fold one at a time, and wrong for a grid that enters
all at once — there the stagger a visitor sees is an accident of where each
item's own top edge sits rather than the sequence that was designed. `arrive`
now takes `shared`, which puts one trigger on the first element and fires the
whole group from it. Per-element stays the default so nothing already correct
changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
…rest

Calling the prop `role` shadowed the real ARIA attribute. The comment in the
file was careful to say it is not an ARIA role and is never forwarded — and
that was true — but the consequence was that no `<Section>` could ever be
given a genuine ARIA role even where one belonged, because the name was
already taken. `band` says what it is: a position in the page's story.

The closed `Props` interface had the same shape of problem one level up.
`<Section aria-labelledby="x">` was an `astro check` error today, for a
perfectly ordinary piece of markup. Props now extend
`HTMLAttributes<'section'>` and everything not consumed here spreads onto the
`<section>`, so ARIA and `data-*` attributes pass through without this file
being edited again.

The rendered attribute stays `data-pcv-section` — the motion scanner scopes
its stagger groups by it and the tests read it — and the invariant that the
band never reaches the DOM as an attribute is unchanged and still asserted.

Also drops `data-pcv-photo` from the homepage mosaic. It was the deleted
`homepage-motion.ts`'s selector and nothing in `src/` reads it now; the
photos are wired by `data-pcv-arrive` like everything else. A dead attribute
that looks like a hook is worse than no attribute, because the next person
has to prove it is dead before removing it.

The spec §5.3 and plan examples are updated to match, so the documents do not
teach a prop name the code no longer accepts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
Every spec ran at Playwright's Desktop Chrome default of 1280px. Both of the
motion system's width gates sit at 1024px, so nothing in the suite could
observe whether they existed — which is precisely how a count-up leaking onto
phones survived review.

A scoped second project rather than a second run of everything. Several specs
encode desktop-only intent on purpose: `scroll-choreography.spec.ts` asserts
the pin holds, and below 1024px it correctly does not. Widening those to pass
at both sizes would delete the assertion that matters. The `mobile-chrome`
project therefore runs only `mobile-*.spec.ts`, and `chromium` ignores them,
so each assertion states the truth for exactly one viewport.

Both projects repeat the `csp.spec.ts` exclusion. A project's own
`testIgnore` replaces the top-level one instead of adding to it, so setting
it on a project silently readmitted csp.spec.ts to a run that cannot serve
its headers.

`mobile-motion.spec.ts` is the assertion that would have caught the bug. It
samples the counters every frame from first paint rather than reading them at
the end, because a count-up that ran *and finished* leaves the true value in
place — an end-state check cannot tell "never animated" from "animated and
landed", which is the other half of why this was invisible. It also scrolls
right through the band rather than using `scrollIntoViewIfNeeded`, which
stops as soon as the section is visible and can leave the trigger's `top 62px`
start unreached, passing for the wrong reason.

Two comment corrections while here. `no-js.spec.ts` claimed to guard against
a later change to a `to()` tween; with JavaScript disabled neither `from()`
nor `to()` runs, so it proves only that nothing pre-hides content before
scripts do — which is worth proving, and is now what it says. And the ARIA
leak check listed `air` and `door`, when `room`, `work` and `invitation` are
equally invalid; it now asserts no section carries an ARIA role at all, which
is both the real invariant and the right one now that `Section` spreads
arbitrary attributes through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
The table recorded `/about/` as a bare `84`. The three runs behind it were
0.77 / 0.84 / 0.95 — an 18-point spread on one machine, in one sitting,
against one build. Spec §5.5's gate is ±3 points, so the noise is six times
the thing being measured, and a reader taking `84` as precise will either
chase a regression that does not exist or wave a real one through.

Records the spread and states how the gate should actually be evaluated: CI
as the enforcement surface, local numbers as indicative, medians of at least
five back-to-back runs compared against each other rather than single runs
compared against this table, and sub-5-point local moves treated as no signal
at all.

Disclosure only. The medians are unchanged and Lighthouse was not re-run —
this is a historical record of one measurement, and rewriting the numbers
would destroy the thing it exists to preserve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
The final review and its fix wave left five things that are real but not
worth another round here — chiefly that arrive()'s shared-trigger option has
no caller yet, and that the new stagger cap can reorder items past index 3,
which is unreachable until a group exceeds four elements.

Recording them in the plan rather than the scratch ledger, because the ledger
is deleted with the workspace and these are inputs to the next slice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
Spec and design-system.md both said yellow; the homepage shipped brand-sky.
The blue was the backdrop for an illustrated skyline deleted in f1ba32a, so
it is set dressing for a set already struck — and it is the only cold colour
in an otherwise entirely warm printed palette, on the one band whose job is
to feel like an invitation.

Records the three consequences slice 3 inherits: map band="door" to yellow,
retire the now-unreferenced brand-sky token rather than leave a colour
nothing uses, and keep contrast.spec.ts passing unmodified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
The stagger was irregular by design and illegible in practice. A 0.09s step
under a 0.7s travel resolved the whole group in 321ms — too fast to read as a
sequence, so the uneven gaps landed as a stutter rather than as people
arriving one at a time.

Judged by comparison rather than argument: three candidate timings side by
side, and the character only began reading at half speed. Both the step and
the travel are doubled, not just the delays, because a halved global time
scale is what was actually looked at. Cards now land at 254/398/642ms under a
1.4s travel.

The cost is stated in the code and is real: the group finishes arriving two
seconds after it triggers. That is the first thing to revisit if the page
reads as sluggish in ordinary scrolling rather than under deliberate replay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
…the copy

The hero was the last thing animating in CSS. It rose on a 0.9s keyframe with
hardcoded 80ms delays while everything below it arrived on GSAP's 1.4s clock —
two engines, two timings, no way to compose them, and they drifted the moment
either was tuned. The first thing a visitor saw was the one part of the page
outside the system meant to make the site feel like one place.

Moving it exposed a worse problem than the engine. Four elements rising one at
a time, evenly spaced, each alone, underneath a headline that reads "You're not
the only one in the room" — the motion was making the opposite case to the copy.

So the hero declares ordinals rather than taking source order: 0, 1, [beat], 3,
3. The place arrives, then you, then a pause longer than any other on the page,
then the tagline and both buttons together. It is the only group on the site
that lands in unison, and the exception is the argument.

data-pcv-order is the whole mechanism. A shared ordinal means unison, because
staggerDelay is deterministic and the same index yields the same delay and the
same jitter. A skipped ordinal buys the beat. No fifth primitive, no second
engine, one new attribute.

Two assertions in motion-preferences changed, deliberately and not to go green:
they pinned `animation-name: pcvRise`, a mechanism that no longer exists. The
replacements are stronger — a real delay value under no-preference, and its
absence plus a painted element under reduce, which catches the failure that
actually hurts (an entrance skipped by hiding rather than by not running).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eR2xmyP6BfBhxhdFu2LEx
…ges (#114)

* chore: Ignore .DS_Store

Browsing public/ in Finder drops these into the directory that gets
deployed verbatim, where they would be served as real files. One was
already sitting in public/images/ untracked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(header): Put the header on the design's dark bar and give it a scroll rule

The redesign's header is a black bar with white nav and a pink rule
underneath tracking how far down the document you are. The bar and the
rule land here; the seven-link nav does not change, for the reason
already recorded in this file — collapsing to the design's three links
would orphan five reachable pages.

The nav's hover and focus colour is untouched and still load-bearing:
the sliding pill follows whichever link you point at, so a hovered link
sits on #FF66A8 and must stay brand-dark to clear 4.5:1. Only the
resting colour inverts, to white/70 on #1A1A1A. The RSVP pill's ink
shadow and border go cream, because a dark offset shadow on a dark bar
is invisible.

Header height goes 68px to 62px. That is the height the motion system's
HEADER_OFFSET has always assumed when it parks a pinned section or lands
a smooth-scrolled anchor; the two disagreed by six pixels until now.

The rule is deliberately not gated on prefers-reduced-motion — it reports
scroll the visitor is already performing rather than animating on a clock
of its own — and deliberately lives in public/ as plain JS. As a
component <script> Astro inlined it, and the enforcing CSP blocked it on
every page, because an inline script needs its sha256 in both policies in
vercel.json. Same trade event-freshness.js already makes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(home): Anchor the next-night bar with the member count

The design anchored this bar with four overlapping member avatars and a
"+422" bubble. We have no member headshots and no consent to put faces on
a public homepage, and three placeholder circles under a social-proof
claim is the failure mode of #98 — so the bubble carries the claim alone.

The number comes from config, the same hand-maintained source StatLine
prints twice further down, so the three can never disagree. brand-dark on
brand-purple is 5.63:1.

The bar renders only when an event is scheduled, so this appears with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(content): Let the gallery collection accept JSON dates

The gallery loader advertises `.json` alongside YAML, but the schema used
`z.date()`, which only ever sees a Date because YAML frontmatter parses
one before Zod runs. JSON has no date type and hands over a string, so
every JSON entry that could be written was rejected at build time.

Latent since the collection was added — it stayed empty, so nothing ever
exercised it. `z.coerce.date()` accepts both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(home): Fill the room mosaic with photos from four real Thursdays

The mosaic has rendered nothing since the redesign landed, because the
gallery collection was empty and the headline it sits under — "Every
photo is a real Thursday" — is a claim only photos can honour.

Six entries from four nights: PATCH 001, the Hackathon Kickoff + Builder
Night, Collab Lab, and the Kickback + Game Night. Event names come from
the attendance records in community/events, not from guesswork. The
homepage shows the five newest, so the Kickback entry is carried but not
currently on the grid.

Every candidate from the May 5 shoot is excluded despite being the
strongest photography we have: that was Philly Tech Week, on a Tuesday,
and the headline says Thursday.

Photos are WebP at 1600px and all under 300KB, cropped from originals
that are kept outside the repo — the source dump was 490MB across 117
files, which git would have carried forever.

Docs pick up the collection, which was previously undocumented, plus the
two non-obvious layout facts: the first entry lands in a double-height
cell and wants a portrait image, and same-day entries order by filename.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf(home): Serve the mosaic at the size it is actually displayed

The tiles render about 194px wide on a phone and 425px on a desktop, but
every file was 1200px. A phone downloaded 674KB to draw five thumbnails,
roughly thirty times the pixels it can show, below the fold, on cellular.

Each image is normalised to a 1200px canonical with `-400` and `-800`
siblings, and the mosaic emits a srcset over that fixed ladder. Measured
on the built site: a 390px phone at 3x goes 674KB to 308KB, desktop at 1x
the same, and desktop at 2x correctly keeps choosing the 1200px file
because at that density it genuinely needs it.

The ladder is fixed rather than derived so every width in the srcset is a
constant the page can state truthfully. e2e/gallery-images.spec.ts fetches
every candidate URL and asserts three rungs per photo, so a contributor
who copies one file instead of three fails CI instead of shipping a 404
that only phones would see.

Not astro:assets, which would generate all of this: its image service
needs sharp, and adding a native binary dependency to a repo aimed at
first-time contributors is a call for the maintainer, not this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: Remove the tracked .DS_Store from public/

It was committed before .DS_Store was ignored, and public/ is copied
verbatim into the deploy — so it was being served as a real file. The
ignore rule added earlier does not untrack a file that is already in the
index.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
website Ready Ready Preview Aug 8, 2026 9:35pm

@traksaw
traksaw marked this pull request as ready for review August 3, 2026 03:33

@traksaw traksaw left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

Reviewed by building the branch in a throwaway worktree and measuring the things worth measuring rather than reasoning about them. Everything marked "measured" below comes from Chromium at 1440×900 against the real build.

Three changes braided together: a homepage rebuilt on the design's tokens, a declarative motion system (data-pcv-arrive / data-pcv-hold scanned by one file, all preference-gating in a single matchMedia), and six real event photos behind a responsive srcset ladder.

The architecture holds up. The primitives/scanner split is the right seam, matchMedia over a JS-branch-plus-parallel-CSS-block is the right call for the reason stated, and the test suite is unusually serious — the mobile-chrome project existing specifically to assert a gate the desktop project cannot see is the kind of thing most repos never do.

The comments are load-bearing and mostly excellent. Two of them are now wrong, which matters more than usual precisely because they are written to be trusted.


Blocking

1. The Join band reintroduces the white-on-pink defect this repo is actively fixing

brand.pink is #FF66A8 — the same token as accent-400. index.astro:369 ships bg-brand-pink text-white. Measured, alpha-composited:

Element Ratio Needs
#join h2 (54px/800) 2.72:1 3.0
#join p (19px, white/90) 2.46:1 4.5
#join StatLine (13px, white/80) 2.23:1 4.5
Hero pill "Come to a night" (21px/700, white on pink) 2.72:1 3.0
.pcv-tag "Builder Night" (11px/700, white on pink) 2.72:1 4.5
.pcv-tag "Collab Lab" (11px, white on brand-purple) ~3.0:1 4.5

This is #96 verbatim, on a new page. #116 is open right now flipping exactly this pairing to text-brand-dark (6.40:1), and it will conflict with this file. The heading fails even the 3:1 large-text threshold.

Fix is the same one #116 lands: text-brand-dark on every pink surface, dark tag text, and drop the white/80–90 opacity steps — they make it worse, not softer.

2. Two more surfaces land under AA

  • text-brand-dark/[0.55] card meta, 12px on white (index.astro:247) — 3.89:1
  • #nights card StatLine, text-brand-dark/60 12px on cream (index.astro:305) — 4.37:1
  • Hero eyebrow text-brand-dark/[0.72], 12px on brand-sky (index.astro:137) — 4.46:1, just under

The 19px/16px body copy at /72 is fine (6.45 and 6.88). It is the 12px tier that fails. /70 on white at 12px gets to ~5.7:1.

Related, and worth knowing before fixing the above: contrast.spec.ts's helper parses color with nums.slice(0, 3) and discards the alpha channel, so translucent text is measured as if opaque. It reports that hero eyebrow as 7.78:1 rather than 4.46:1. That blind spot cost nothing before; this page is built on /72, /60, /55 text, so it does now.

3. The stagger inverts past the cap — measured on the mosaic

data-pcv-delay on the five #nights tiles, read off the live page:

[0, 0.254, 0.398, 0.642, 0.556]

Tile 5 arrives 86ms before tile 4. Past STAGGER_CAP the cumulative term is constant, so JITTER alone orders the tail — and JITTER[3]=0.102 > JITTER[4]=0.016.

That is the same defect the comment at primitives.ts:42-48 says it exists to fix ("element four arrives a full step before element three — visibly wrong"), reintroduced at smaller amplitude. The comment's claim that Math.min "keeps the sequence ordered up to the cap and then simply stops the wait growing" is true for the first half and false for the second.

motion-system.spec.ts asserts strict monotonicity — but only on #what .pcv-card, a 3-element group that never reaches the cap. The five-tile mosaic is the only group on the page that does, and nothing checks it.

Either make the tail monotone (a decaying step, e.g. base * (CAP + Math.log1p(i - CAP)), keeps growth bounded and ordered), or keep the flat tail and drop the jitter past the cap so they genuinely land together. Then point the monotonicity assertion at #nights, not #what.

4. The skip link stops skipping

smoothAnchors binds every a[href^="#"] (primitives.ts:250). That includes BaseLayout's "Skip to main content".

Measured on the homepage: Tab → Enter leaves document.activeElement on the skip link itself and location.hash empty. preventDefault() suppresses the browser's fragment navigation, which is what moves the sequential-focus starting point into <main> — so a keyboard user activates the skip link and the next Tab goes straight back into the nav.

Two fixes, both needed:

  • Exclude the skip link (:not([data-pcv-no-smooth]), or skip #main-content), and
  • after the scroll settles, history.pushState(null, '', sel) and focus the target (el.setAttribute('tabindex','-1'); el.focus({ preventScroll: true })).

The missing hash also means "See what happens" → #nights no longer updates the URL, so section links cannot be shared or backed out of.


Non-blocking

  • arrive()'s options are unreachable. The scanner only ever calls arrive(group)opts.y and opts.shared have no caller and no markup hook. The mosaic is precisely the grid the shared doc comment describes ("a photo mosaic… one trigger on the first element fires them all"), and it does not get one. Either wire it (data-pcv-arrive="shared") or delete the parameter; a documented option nothing can reach is worse than neither.
  • No test asserts the pinned section's children end visible. I probed it — all six #nights [data-pcv-arrive] end at opacity 1, so this is not a live bug. But those triggers live inside a pinned container without GSAP's pinnedContainer set, so their cached start positions do not account for the pin. Today the geometry happens to work. no-js.spec checks opacity only with JS off; motion-preferences checks #what; gallery-images reads currentSrc, which is happy at opacity 0. A stuck-invisible mosaic would ship green. One assertion on #nights [data-pcv-arrive] opacity, JS on, desktop, closes it.
  • The sixth photo never renders. Six gallery entries, .slice(0, 5). kickback-game-night-group (2025-08-28) is oldest and always dropped — its JSON and three webp files ship unused. The description says six entries were added. Bump the slice or say the sixth is a bench.
  • srcset() assumes .webp. image.replace(/\.webp$/, '') turns a .jpg entry into foo.jpg-400.webp. The e2e catches it as a 404, but z.string().regex(/\.webp$/) on the schema catches it at build with a better message.
  • The ladder's width claim is not verified. ${image} 1200w is asserted by convention; gallery-images.spec proves the URLs resolve, not that the canonical is 1200px. A 900px file labelled 1200w makes the browser pick the wrong rung and nothing goes red. A naturalWidth check is a two-line addition to that spec.
  • Stale comment. Header.astro:33 still says "driven by src/scripts/scroll-progress.ts". It is public/js/scroll-progress.js — and the description devotes a paragraph to why. Worth fixing precisely because these comments are otherwise reliable.
  • FOUC on the hero. gsap.from with immediateRender means the hero paints from HTML, then the deferred module yanks it to opacity 0 and replays it. Above the fold, on a cold cache, that is a visible flash-and-rewind. No test can see it; worth one look under throttling before merge.
  • staggerDelay with a negative ordinal returns NaN (JITTER[-1] is undefined). data-pcv-order="-1" is unlikely, but Math.max(0, …) is free.

Risks

  • Bundle: GSAP core + ScrollTrigger + ScrollToPlugin is ~70KB min / ~30KB gzip, homepage only. Justified by the pin (correctly argued in scroll-choreography.spec), but docs/perf-baseline.md predates both the motion system and the mosaic and says so itself. Re-baseline before that ±3 gate is enforced.
  • Merge conflict: this file collides with #116 on the pink CTAs. Whichever lands second has to reconcile — worth deciding the order now.
  • Security: no new surface. Keeping GSAP out of a CDN and scroll-progress.js in public/ both preserve script-src 'self'; the reasoning is right and worth keeping in the comments.

Verdict: the architecture holds up and the test suite is better than most of what it is testing. Fix the contrast (1–2), the stagger tail (3), and the skip link (4) — the first and last are accessibility regressions on the site's front door, and all three are small edits against a design that is otherwise sound.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant